########################################################################
#  Searx-Qt - Lightweight desktop application for Searx.
#  Copyright (C) 2020-2022  CYBERDEViL
#
#  This file is part of Searx-Qt.
#
#  Searx-Qt is free software: you can redistribute it and/or modify
#  it under the terms of the GNU General Public License as published by
#  the Free Software Foundation, either version 3 of the License, or
#  (at your option) any later version.
#
#  Searx-Qt is distributed in the hope that it will be useful,
#  but WITHOUT ANY WARRANTY; without even the implied warranty of
#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#  GNU General Public License for more details.
#
#  You should have received a copy of the GNU General Public License
#  along with this program.  If not, see <https://www.gnu.org/licenses/>.
#
########################################################################

from requests.status_codes import _codes as StatusCodes

from PyQt5.QtWidgets import (
    QAbstractItemView,
    QCheckBox,
    QComboBox,
    QDialog,
    QFormLayout,
    QHBoxLayout,
    QHeaderView,
    QLabel,
    QMessageBox,
    QMenu,
    QSpinBox,
    QTableWidget,
    QTableWidgetItem,
    QTabWidget,
    QVBoxLayout,
    QWidget
)

from PyQt5.QtGui import QGuiApplication

from PyQt5.QtCore import Qt, QVariant, QItemSelectionModel

from searxqt.core.http import ErrorType, ErrorTypeStr
from searxqt.core.guard import ConsequenceType, ConsequenceTypeStr

from searxqt.widgets.buttons import Button

from searxqt.translations import _, timeToString, durationMinutesToString


class GuardSettings(QWidget):
    def __init__(self, guard, parent=None):
        QWidget.__init__(self, parent=parent)
        self.__guard = guard

        layout = QVBoxLayout(self)

        # Title label
        layout.addWidget(QLabel(f"<h2>{_('Guard')}</h2>", self))

        # Info label
        infoLabel = QLabel(
            _("Guard can put failing instances on the blacklist or on a"
              " timeout based on set rules below."),
            self
        )
        infoLabel.setWordWrap(True)
        layout.addWidget(infoLabel)

        # Enable checkbox
        self.__enableCheck = QCheckBox(_("Enable guard"), self)
        layout.addWidget(self.__enableCheck)

        # Store log checkbox
        self.__storeLogCheck = QCheckBox(_("Store log"), self)
        layout.addWidget(self.__storeLogCheck)
        # Max log period
        self.__maxLogPeriod = QSpinBox(self)
        self.__maxLogPeriod.setSuffix(_(" days"))
        self.__maxLogPeriod.setMinimum(1)
        self.__maxLogPeriod.setMaximum(999)
        self.__maxLogPeriod.setEnabled(False)
        layout.addWidget(self.__maxLogPeriod, 0, Qt.AlignLeft)

        # Tab widget
        self.__tabWidget = QTabWidget(self)
        self.__tabWidget.setTabShape(QTabWidget.Triangular)
        self.__tabWidget.setTabPosition(QTabWidget.West)
        layout.addWidget(self.__tabWidget)

        # Rule editor
        self.__ruleEditor = GuardRuleEditor(guard, self)
        self.__tabWidget.addTab(self.__ruleEditor, _("Rules"))

        # Log viewer
        self.__logViewer = GuardLogViewer(guard, self)
        self.__tabWidget.addTab(self.__logViewer, _("Log"))

        # Initial state
        self.__enableCheck.setChecked(self.__guard.isEnabled())
        self.__storeLogCheck.setChecked(self.__guard.doesStoreLog())
        self.__maxLogPeriod.setEnabled(self.__guard.doesStoreLog())
        self.__maxLogPeriod.setValue(self.__guard.maxLogPeriod())

        # Connections
        self.__enableCheck.stateChanged.connect(self.__enableCheckStateChange)
        self.__storeLogCheck.stateChanged.connect(self.__storeLogStateChange)
        self.__maxLogPeriod.valueChanged.connect(self.__logPeriodChanged)

    def __enableCheckStateChange(self, state):
        self.__guard.setEnabled(bool(state))

        if not state:
            # Guard disabled; ask to clear the log (when any present)
            if self.__guard.log():
                answer = QMessageBox.question(
                    self,
                    _("Clear log?"),
                    _("You've disabled Guard but there are currently logs"
                      " present. Do you want to clear the log?"),
                    QMessageBox.Yes | QMessageBox.No
                )
                if answer == QMessageBox.Yes:
                    self.__logViewer.clearLog()

    def __storeLogStateChange(self, state):
        state = bool(state)
        self.__guard.setStoreLog(state)
        self.__maxLogPeriod.setEnabled(state)

    def __logPeriodChanged(self, value):
        self.__guard.setMaxLogPeriod(value)


class GuardRuleEditor(QWidget):
    def __init__(self, guard, parent=None):
        QWidget.__init__(self, parent=parent)
        self.__guard = guard

        layout = QVBoxLayout(self)

        toolBar = QWidget(self)
        toolBarLayout = QHBoxLayout(toolBar)

        self.__addButton = Button(_("Add"), self)
        self.__editButton = Button(_("Edit"), self)
        self.__editButton.setEnabled(False)
        self.__delButton = Button(_("Del"), self)
        self.__delButton.setEnabled(False)
        self.__upButton = Button("▲", self)
        self.__upButton.setToolTip(_("Move rule up."))
        self.__downButton = Button("▼", self)
        self.__downButton.setToolTip(_("Move rule down."))

        toolBarLayout.addWidget(self.__addButton, 0, Qt.AlignLeft)
        toolBarLayout.addWidget(self.__editButton, 0, Qt.AlignLeft)
        toolBarLayout.addWidget(self.__delButton, 0, Qt.AlignLeft)
        toolBarLayout.addWidget(self.__upButton, 0, Qt.AlignLeft)
        toolBarLayout.addWidget(self.__downButton, 1, Qt.AlignLeft)

        self.__rulesTable = QTableWidget(self)
        self.__rulesTable.setEditTriggers(QAbstractItemView.NoEditTriggers)
        self.__rulesTable.setSelectionBehavior(QAbstractItemView.SelectRows)
        self.__rulesTable.setSelectionMode(QAbstractItemView.SingleSelection)

        layout.addWidget(toolBar)
        layout.addWidget(self.__rulesTable)

        # Fill the table
        self.updateTable()

        # Connections
        self.__addButton.clicked.connect(self.__addRuleDialog)
        self.__editButton.clicked.connect(self.__editSelectedRule)
        self.__delButton.clicked.connect(self.__delSelectedRule)
        self.__upButton.clicked.connect(self.moveSelectedUp)
        self.__downButton.clicked.connect(self.moveSelectedDown)
        self.__rulesTable.itemSelectionChanged.connect(self.__selectionChanged)

    def moveSelected(self, amount=-1):
        """
        @param amount: index offset to move.
        @type amount: int
        """
        # move the selected rule.
        index = self.__rulesTable.currentIndex().row()
        toIndex = index + amount
        self.__guard.moveRule(index, toIndex)

        # update the view.
        self.updateTable()

        # re-select the selected rule on new index.
        item = self.__rulesTable.item(toIndex, 0)
        self.__rulesTable.setCurrentItem(
            item,
            QItemSelectionModel.Rows | QItemSelectionModel.SelectCurrent
        )

    def moveSelectedUp(self):
        self.moveSelected(-1)

    def moveSelectedDown(self):
        self.moveSelected(1)

    def updateTable(self):
        self.__rulesTable.clear()
        # Columns
        # - errorType
        # - amount
        # - period
        # - statusCode
        # - destinationType
        # - duration
        self.__rulesTable.setColumnCount(6)
        self.__rulesTable.setHorizontalHeaderLabels([
            "Error Type",
            "Amount",
            "Timeframe",
            "Status",
            "Destination",
            "Duration"
        ])

        rules = self.__guard.rules()
        self.__rulesTable.setRowCount(len(rules))
        index = 0
        for rule in rules:
            self.updateRuleIndex(
                index,
                rule.condition.errorType,
                rule.condition.amount,
                rule.condition.period,
                rule.condition.status,
                rule.consequence.type,
                rule.consequence.duration
            )
            index += 1

    def __delSelectedRule(self):
        index = self.__rulesTable.currentRow()
        self.__guard.delRule(index)
        self.__rulesTable.removeRow(index)

    def __editSelectedRule(self):
        # Get data of selected rule.
        rowIndex = self.__rulesTable.currentRow()
        errorType = self.__rulesTable.item(rowIndex, 0).data(Qt.UserRole)
        amount = self.__rulesTable.item(rowIndex, 1).data(Qt.UserRole)
        period = self.__rulesTable.item(rowIndex, 2).data(Qt.UserRole)
        statusCode = self.__rulesTable.item(rowIndex, 3).data(Qt.UserRole)
        destinationType = self.__rulesTable.item(rowIndex, 4).data(Qt.UserRole)
        duration = self.__rulesTable.item(rowIndex, 5).data(Qt.UserRole)

        # Open edit dialog with data from the selected rule.
        dialog = GuardEditRuleDialog(
            errorType=errorType,
            amount=amount,
            period=period,
            statusCode=statusCode,
            destinationType=destinationType,
            duration=duration
        )

        if dialog.exec():
            # Accept button is pressed; update the data.
            rule = self.__guard.getRule(rowIndex)
            rule.condition.errorType = dialog.errorType()
            rule.condition.amount = dialog.amount()
            rule.condition.period = dialog.period()
            rule.condition.status = dialog.statusCode()

            rule.consequence.type = dialog.destinationType()
            rule.consequence.duration = dialog.duration()

            self.updateTable()  # Re-generate the table.

    def __selectionChanged(self):
        # When no rule is selected disable the edit and del buttons.
        if self.__rulesTable.currentItem():
            self.__editButton.setEnabled(True)
            self.__delButton.setEnabled(True)

            # Only enable up when the item can go up (index > 0)
            currentIndex = self.__rulesTable.currentIndex().row()
            if currentIndex:
                self.__upButton.setEnabled(True)
            else:
                self.__upButton.setEnabled(False)

            # Only enable down when the item can go down
            if currentIndex < self.__rulesTable.rowCount() - 1:
                self.__downButton.setEnabled(True)
            else:
                self.__downButton.setEnabled(False)
        else:
            self.__editButton.setEnabled(False)
            self.__delButton.setEnabled(False)
            self.__upButton.setEnabled(False)
            self.__downButton.setEnabled(False)

    def updateRuleIndex(
        self, index, errorType, amount,
        period, statusCode, destinationType, duration
    ):
        # errorType
        tableItem = QTableWidgetItem(ErrorTypeStr[errorType])
        tableItem.setData(Qt.UserRole, QVariant(errorType))
        self.__rulesTable.setItem(index, 0, tableItem)
        # amount
        str_ = str(amount) + "x"
        tableItem = QTableWidgetItem(str_)
        tableItem.setData(Qt.UserRole, QVariant(amount))
        self.__rulesTable.setItem(index, 1, tableItem)
        # period
        if period:
            str_ = durationMinutesToString(period)
        else:
            str_ = _("Always")
        tableItem = QTableWidgetItem(str_)
        tableItem.setData(Qt.UserRole, QVariant(period))
        self.__rulesTable.setItem(index, 2, tableItem)
        # statusCode
        if statusCode:
            str_ = str(statusCode)
        else:
            str_ = "-"
        tableItem = QTableWidgetItem(str_)
        tableItem.setData(Qt.UserRole, QVariant(statusCode))
        self.__rulesTable.setItem(index, 3, tableItem)
        # destinationType
        tableItem = QTableWidgetItem(ConsequenceTypeStr[destinationType])
        tableItem.setData(Qt.UserRole, QVariant(destinationType))
        self.__rulesTable.setItem(index, 4, tableItem)
        # duration
        if duration:
            str_ = durationMinutesToString(duration)
        elif destinationType == ConsequenceType.Blacklist:
            str_ = "-"
        else:
            str_ = _("Until restart")
        tableItem = QTableWidgetItem(str_)
        tableItem.setData(Qt.UserRole, QVariant(duration))
        self.__rulesTable.setItem(index, 5, tableItem)

    def __addRuleDialog(self):
        dialog = GuardEditRuleDialog()
        if dialog.exec():
            # errorType
            # amount
            # period
            # statusCode
            # destinationType
            # duration

            rowIndex = self.__rulesTable.rowCount()
            self.__rulesTable.setRowCount(rowIndex + 1)

            # Add view
            self.updateRuleIndex(
                rowIndex,
                dialog.errorType(),
                dialog.amount(),
                dialog.period(),
                dialog.statusCode(),
                dialog.destinationType(),
                dialog.duration()
            )

            # Add the rule itself to Guard
            self.__guard.addRule(
                dialog.errorType(),
                dialog.destinationType(),
                amount=dialog.amount(),
                period=dialog.period(),
                status=dialog.statusCode(),
                duration=dialog.duration()
            )


class GuardEditRuleDialog(QDialog):
    def __init__(
        self,
        errorType=ErrorType.WrongStatus,
        amount=0,
        period=0,
        statusCode=None,
        destinationType=ConsequenceType.Timeout,
        duration=0,
        parent=None
    ):
        QDialog.__init__(self, parent=parent)
        self.setWindowTitle(_("Guard rule editor"))
        layout = QFormLayout(self)

        # errorType QComboBox
        # amount QSpinBox
        # period QSpinBox
        # status QComboBox
        # destination QComboBox
        # duration QSpinBox

        # Error type
        self.__errorTypeCombo = QComboBox(self)
        for errType, errTypeStr in ErrorTypeStr.items():
            # Skip Success and ProxyError
            if (errType == ErrorType.Success or
                errType == ErrorType.ProxyError):
                continue
            self.__errorTypeCombo.addItem(errTypeStr, QVariant(errType))
        index = self.__errorTypeCombo.findData(QVariant(errorType))
        self.__errorTypeCombo.setCurrentIndex(index)
        layout.addRow(QLabel(_("Error type") + ":"), self.__errorTypeCombo)

        # Amount of failes to trigger.
        self.__amountSpin = QSpinBox(self)
        self.__amountSpin.setRange(0, 64)
        self.__amountSpin.setValue(amount)
        label = QLabel(_("Amount of failes to trigger") + ":")
        label.setWordWrap(True)
        layout.addRow(label, self.__amountSpin)

        # Timeframe in minutes where the amount of failes have to occur in.
        self.__periodSpin = QSpinBox(self)
        self.__periodSpin.setSuffix(_("min"))
        self.__periodSpin.setRange(0, 6000)
        self.__periodSpin.setValue(period)
        label = QLabel(
            _("Timeframe in minutes where the amount of failes have to occur"
              " in") + ":"
        )
        label.setWordWrap(True)
        layout.addRow(label, self.__periodSpin)

        # Status code
        self.__statusCodeCombo = QComboBox(self)
        self.__statusCodeCombo.addItem(_("All"), QVariant(None))
        for code, statusStr in StatusCodes.items():
            if code >= 400:
                self.__statusCodeCombo.addItem(
                    str(code) + " - " + statusStr[0],
                    QVariant(code)
                )
        index = self.__statusCodeCombo.findData(QVariant(statusCode))
        self.__statusCodeCombo.setCurrentIndex(index)
        label = QLabel(_("Response status code") + ":")
        label.setWordWrap(True)
        layout.addRow(label, self.__statusCodeCombo)

        # Destination
        self.__destinationCombo = QComboBox(self)
        for destType, destStr in ConsequenceTypeStr.items():
            self.__destinationCombo.addItem(destStr, QVariant(destType))
        index = self.__destinationCombo.findData(QVariant(destinationType))
        self.__destinationCombo.setCurrentIndex(index)
        label = QLabel(_("Destination") + ":")
        label.setWordWrap(True)
        layout.addRow(label, self.__destinationCombo)

        # Duration
        self.__durationSpin = QSpinBox(self)
        self.__durationSpin.setSuffix(_("min"))
        self.__durationSpin.setRange(0, 6000)
        self.__durationSpin.setValue(duration)
        label = QLabel(_("Duration of the timeout") + ":")
        label.setWordWrap(True)
        layout.addRow(label, self.__durationSpin)

        # Cancel/Save button
        cancelButton = Button(_("Cancel"), self)
        saveButton = Button(_("Save"), self)
        layout.addRow(cancelButton, saveButton)

        # Initial enable / disable errorType/destination combo
        self.__errorTypeChanged(self.__errorTypeCombo.currentIndex())
        self.__destinationChanged(self.__destinationCombo.currentIndex())

        # Connections
        self.__errorTypeCombo.currentIndexChanged.connect(
            self.__errorTypeChanged
        )
        self.__destinationCombo.currentIndexChanged.connect(
            self.__destinationChanged
        )
        cancelButton.clicked.connect(self.reject)
        saveButton.clicked.connect(self.accept)

    def errorType(self):
        return self.__errorTypeCombo.currentData()

    def amount(self):
        return self.__amountSpin.value()

    def period(self):
        return self.__periodSpin.value()

    def statusCode(self):
        errorType = self.__errorTypeCombo.currentData()
        if errorType == ErrorType.WrongStatus:
            return self.__statusCodeCombo.currentData()
        return None

    def destinationType(self):
        return self.__destinationCombo.currentData()

    def duration(self):
        destType = self.__destinationCombo.currentData()
        if destType == ConsequenceType.Timeout:
            return self.__durationSpin.value()
        return 0

    def __errorTypeChanged(self, index):
        # The status code combobox should only be enabled when the WrongStatus
        # errorType is selected.
        errorType = self.__errorTypeCombo.itemData(index)
        if errorType == ErrorType.WrongStatus:
            self.__statusCodeCombo.setEnabled(True)
        else:
            self.__statusCodeCombo.setEnabled(False)

    def __destinationChanged(self, index):
        # The duration spinbox should only be enabled when the destinationType
        # is Timeout.
        destType = self.__destinationCombo.itemData(index)
        if destType == ConsequenceType.Timeout:
            self.__durationSpin.setEnabled(True)
        else:
            self.__durationSpin.setEnabled(False)


class GuardLogViewer(QWidget):
    def __init__(self, guard, parent=None):
        QWidget.__init__(self, parent=parent)
        self.__guard = guard

        layout = QVBoxLayout(self)

        layout.addWidget(QLabel("<h2>Log</h2>", self), 0, Qt.AlignTop)

        # Toolbar
        buttonLayout = QHBoxLayout()

        self.__refreshButton = Button(_("Refresh"), self)
        buttonLayout.addWidget(self.__refreshButton, 0, Qt.AlignLeft)

        self.__clearButton = Button(_("Clear"), self)
        buttonLayout.addWidget(self.__clearButton, 1, Qt.AlignLeft)

        layout.addLayout(buttonLayout)

        # Table
        self.__logTable = QTableWidget(self)
        self.__logTable.setEditTriggers(QAbstractItemView.NoEditTriggers)
        self.__logTable.setSelectionBehavior(QAbstractItemView.SelectRows)
        self.__logTable.setContextMenuPolicy(Qt.CustomContextMenu)
        layout.addWidget(self.__logTable)

        self.__refreshButton.clicked.connect(self.updateTable)
        self.__clearButton.clicked.connect(self.__clearLogPressed)
        self.__logTable.customContextMenuRequested.connect(self.__contextMenu)

        self.updateTable()

    def __contextMenu(self, pos):
        selectionModel = self.__logTable.selectionModel()
        if selectionModel.hasSelection():
            indexes = selectionModel.selectedRows(column=1)
            menu = QMenu(self)

            # Copy URL
            copyUrlAction = menu.addAction(_("Copy URL"))
            # Clear log entries for instance(s)
            removeInstanceEntriesAction = menu.addAction(
                _("Clear log for selected instance(s)")
            )
            # Exec the menu
            action = menu.exec_(self.__logTable.mapToGlobal(pos))

            if action == copyUrlAction:
                index = indexes[0]
                item = self.__logTable.item(index.row(), index.column())
                url = item.data(Qt.UserRole)
                clipboard = QGuiApplication.clipboard()
                clipboard.setText(url)

            elif action == removeInstanceEntriesAction:
                removedInstances = []
                for modelIndex in indexes:
                    item = self.__logTable.item(
                        modelIndex.row(), modelIndex.column()
                    )
                    url = item.data(Qt.UserRole)

                    if url in removedInstances:
                        continue

                    self.__guard.clearInstanceLog(url)
                    removedInstances.append(url)

                self.updateTable()

    def __clearLogPressed(self):
        answer = QMessageBox.question(
            self,
            _("Confirm clear log"),
            _("Clear log?"),
            QMessageBox.Yes | QMessageBox.No
        )
        if answer == QMessageBox.Yes:
            self.clearLog()

    def clearLog(self):
        self.__guard.clearLog()
        self.updateTable()

    def updateTable(self):
        # Disable sorting, else it will mess up when already sorted.
        self.__logTable.setSortingEnabled(False)

        self.__logTable.clear()
        self.__logTable.setColumnCount(5)
        self.__logTable.setHorizontalHeaderLabels([
            "datetime",
            "instance",
            "error",
            "status",
            "content"
        ])
        self.__logTable.horizontalHeader().setStretchLastSection(True)
        self.__logTable.horizontalHeader().setSectionResizeMode(
            QHeaderView.ResizeToContents
        )

        # Calc row count
        rowCount = 0
        log = self.__guard.log()
        for url in log:
            for errType in log[url]:
                rowCount += len(log[url][errType])

        self.__logTable.setRowCount(rowCount)

        index = 0
        for url in log:
            for errType in log[url]:
                errCount = len(log[url][errType])
                for logTime, status, content in log[url][errType]:
                    # Datetime
                    tableItem = QTableWidgetItem(
                        timeToString(logTime * 60, fmt="%D %H:%M:%S")
                    )
                    tableItem.setData(Qt.UserRole, QVariant(logTime))
                    self.__logTable.setItem(index, 0, tableItem)

                    # Url
                    tableItem = QTableWidgetItem(url)
                    tableItem.setData(Qt.UserRole, QVariant(url))
                    tableItem.setToolTip(str(errCount))
                    self.__logTable.setItem(index, 1, tableItem)

                    # Error
                    tableItem = QTableWidgetItem(ErrorTypeStr[errType])
                    self.__logTable.setItem(index, 2, tableItem)

                    # Status
                    tableItem = QTableWidgetItem(str(status))
                    if status:
                        tableItem.setToolTip(str(StatusCodes[status]))
                    self.__logTable.setItem(index, 3, tableItem)

                    # Content
                    tableItem = QTableWidgetItem(str(content))
                    self.__logTable.setItem(index, 4, tableItem)

                    index += 1

        # Enable sorting again (restore the set sorting.)
        self.__logTable.setSortingEnabled(True)
